﻿module internal FODATAC.Query

open System
open System.Collections.Generic
open HTTP
open Microsoft.Data.OData
open Microsoft.Data.Edm
open System.Reflection
open System.Runtime.Serialization
open System.Threading.Tasks
open FODATAC.Interfaces
open FODATAC.Reflection
open System.Linq

/// <summary>
/// sets a property via reflection and adjusts a setter so that we can track that value
/// </summary>
/// <param name="setter">setter for the member</param>
/// <param name="instance">parent entity</param>
/// <param name="value">value to set</param>
/// <param name="instanceIsEntity">if instance is entity. This is because we only want to track entities properties but not others like complex values</param>
/// <param name="session">Isession</param>
let setValue setter instance (value : obj) instanceIsEntity (session : ISessionInternal) = 
    let v, mem, ttype = getValueAndMemberName setter instance
    
    //if value is a collection we wrap it to correct type
    (*
    let value : obj = 
        match getGenericCollectionType ttype with
        | Some(col) -> 
            let genType = col.MakeGenericType(ttype.GetGenericArguments())
            Activator.CreateInstance(genType, value)
        | _ -> value
        *)
    
    //we copy the collection to another collection so that we can track old values
    let getColValue oldValue = 
        match oldValue : Object with
        | :? IEnumerable<obj> as col when col <> null -> Activator.CreateInstance(col.GetType(), col)
        | _ -> oldValue
    
    match setter with
    | Setter.Field(fi) -> fi.SetValue(instance, value)
    | Setter.Property(pi) -> pi.SetValue(instance, value, null)
    //adjust the setters
    if instanceIsEntity then 
        let bool, list = session.SettersByEntity.TryGetValue instance
        let value = getColValue value
        match (bool) with
        | true -> list.Add(setter, value)
        | _ -> 
            let list = List()
            list.Add(setter, value)
            session.SettersByEntity.Add(instance, list)
        value
    else value

/// <summary>
/// creates a new instance of T ands fills it with appropriate data
/// </summary>
/// <param name="reader">reader</param>
/// <param name="session">current ISesssion</param>
let rec internal createAndFillInstance<'T> (reader : ODataReader, session : ISessionInternal) : 'T = 
    let currenttype = typeof<'T>
    match getGenericCollectionType currenttype with
    // a collection
    | Some(t) -> 
        let mi = fillListMethodInfo.MakeGenericMethod(currenttype.GenericTypeArguments)
        mi.Invoke(null, 
                  [| reader :> Object
                     session :> Object |]) :?> 'T
    // non collection
    | _ -> 
        /// loop each property data and fill properties
        let rec fillProperties (instance : System.Object, properties : IEnumerable<ODataProperty>, instanceIsEntity : bool) : obj = 
            //for complex types
            let fillComplex (ttype : System.Type, complex : ODataComplexValue) = 
                let instance = createInstance ttype
                fillProperties (instance, complex.Properties, false)
            
            let t = instance.GetType()
            let getTypeKind = session.SessionFactory.MetaData.GetTypeKind(t)
            match getTypeKind with
            // prevent self assignment happens when we select single primitive
            | EdmTypeKind.Primitive -> 
                let prop = properties |> Seq.exactlyOne
                prop.Value
            //prevent self assignment, happens we select a single complex type
            | EdmTypeKind.Complex when properties
                                       |> Seq.length = 1 -> 
                let prop = properties |> Seq.exactlyOne
                let value = prop.Value
                if value = null then null
                else fillComplex (instance.GetType(), value :?> ODataComplexValue)
            | _ -> 
                // all these are simple properties
                for prop in properties do
                    let property = t.GetRuntimeProperty(prop.Name)
                    if not (property <> null && property.PropertyType.GetTypeInfo().IsAssignableFrom(typeof<System.IO.Stream>.GetTypeInfo())) then 
                        let setter, memberType = (findSetter (t, prop.Name)).Value
                        match prop.Value with
                        // collection of complex values
                        | :? ODataCollectionValue as coll -> 
                            let items = coll.Items
                            
                            let collection, ttype = 
                                match setter with
                                | Setter.Field(fi) -> (fi.GetValue(instance), fi.FieldType)
                                | Setter.Property(pi) -> (pi.GetValue(instance, null), pi.PropertyType)
                            
                            // detect the proper collection type and add items via reflection
                            let collection, setterCol = 
                                match (collection) with
                                | null -> 
                                    let genType = (getGenericCollectionType ttype).Value
                                    let coll = genType.MakeGenericType(ttype.GenericTypeArguments) |> Activator.CreateInstance
                                    let setterCol = setValue setter instance coll instanceIsEntity session
                                    coll, setterCol
                                | _ -> 
                                    let setterCol = setValue setter instance collection instanceIsEntity session
                                    collection, setterCol
                            
                            let meth = collection.GetType().GetRuntimeMethods() |> Seq.find( fun c -> c.Name =("Add"))
                            // this is for tracking.
                            let setterMeth = setterCol.GetType().GetRuntimeMethods() |> Seq.find( fun c -> c.Name =("Add"))
                            for item in items do
                                let item = 
                                    match (item) with
                                    | :? ODataComplexValue as complex -> fillComplex (ttype.GenericTypeArguments.[0], complex)
                                    | _ -> item
                                meth.Invoke(collection, [| item |]) |> ignore
                                setterMeth.Invoke(setterCol, [| item |]) |> ignore
                        // singular values
                        | value -> 
                            let value = 
                                match value with
                                | :? ODataComplexValue as complex -> fillComplex (memberType, complex)
                                | _ -> value
                            setValue setter instance value instanceIsEntity session |> ignore
                instance
        
        let mutable instance = createInstance typeof<'T> :?> 'T
        let mutable navigationProperty = None
        
        let rec loop (navigationProperty : option<ODataNavigationLink>, instance : 'T) : 'T = 
            let cont (navigationProperty, instance) = 
                reader.Read() |> ignore
                loop (navigationProperty, instance)
            
            //if entity is in the session return that one instead of creating
            let getCorrectInstance newInstance = 
                let entry = reader.Item :?> ODataEntry
                if entry = null then null
                else 
                    let id = entry.Id
                    
                    let getCollectionName (url : string) = 
                        let lastPart = url.Trim().Trim('/').Split([| '/' |]) |> Seq.last
                        let indexOfParan = lastPart.IndexOf('(')
                        lastPart.Substring(0, indexOfParan)
                    // in case of select projection we type and collection name is different at the begining. we
                    // are only intereted in type itself
                    if session.SessionFactory.MetaData.GetCollectionName(typeof<'T>) <> getCollectionName id then 
                        //session.AddLoadedEntity id newInstance
                        newInstance
                    else 
                        match session.GetLoadedEntity id with
                        | Some(entity) -> entity
                        | _ -> 
                            session.AddLoadedEntity id newInstance
                            newInstance
            
            let fillInstance instance = 
                if not (session.Contains instance) then 
                    let entry = reader.Item :?> ODataEntry
                    let res = fillProperties (instance, entry.Properties, true)
                    session.AddToSession instance |> ignore
                    let id = entry.Id
                    session.AddToReverseDictionary id instance
                    res
                    
                else instance
            
            match (reader.State, navigationProperty) with
            // collection association
            // inside the entity, a filled navigation detected so lets fill it
            | ODataReaderState.FeedStart, Some(nav) -> 
                let name = nav.Name
                let setter, ttype = (findSetter (typeof<'T>, name)).Value
                let genMethod = fillListMethodInfo.MakeGenericMethod(ttype.GenericTypeArguments)
                let genType = (getGenericCollectionType ttype).Value
                
                let currentParams = 
                    [| reader :> Object
                       session :> Object |]
                
                let result = genMethod.Invoke(null, currentParams)
                setValue setter instance result true session |> ignore
                cont (navigationProperty, instance)
            | ODataReaderState.NavigationLinkStart, _ -> 
                let navigationProperty = Some(reader.Item :?> ODataNavigationLink)
                cont (navigationProperty, instance)
            | ODataReaderState.NavigationLinkEnd, _ -> cont (None, instance)
            // single entity assoc
            | ODataReaderState.EntryStart, Some(nav) -> 
                let name = nav.Name
                match findSetter (typeof<'T>, name) with
                | Some(setter, ttype) -> 
                    //recurse for a single entity association
                    let meth = createAndFillInstanceMethodInfo.MakeGenericMethod(ttype)
                    let result = meth.Invoke(null, [| reader; session |])
                    setValue setter instance result true session |> ignore
                    cont (navigationProperty, instance)
                | _ -> 
                    let instance = getCorrectInstance instance :?> 'T
                    cont (None, instance)
            | ODataReaderState.EntryStart, None -> 
                let entry = reader.Item :?> ODataEntry
                if entry = null then Unchecked.defaultof<'T>
                else 
                    let typeName = entry.TypeName
                    let ttype = typeof<'T>
                    let fqdn = MetaData.GetQualifiedName ttype
                    
                    let instance = 
                        if typeName <> fqdn then 
                            let derivedType = 
                                seq { 
                                    for attr in ttype.GetTypeInfo().GetCustomAttributes(typeof<KnownTypeAttribute>, true) do
                                        let knownTypeAtt = attr :?> KnownTypeAttribute
                                        if knownTypeAtt.Type
                                           |> MetaData.GetQualifiedName = typeName then yield knownTypeAtt.Type
                                }
                                |> Seq.tryFind (fun _ -> true)
                            match derivedType with
                            | Some d -> 
                                let instance = createInstance d :?> 'T
                                getCorrectInstance instance :?> 'T
                            | _ -> getCorrectInstance instance :?> 'T
                        else (getCorrectInstance instance) :?> 'T
                    if (Object.Equals(instance, null)) then Unchecked.defaultof<'T>
                    else cont (navigationProperty, instance)
            // we have all the data let's fill the entity
            | ODataReaderState.EntryEnd, None -> 
                if not (Object.Equals(instance, (Unchecked.defaultof<'T>))) then fillInstance instance :?> 'T
                else Unchecked.defaultof<'T>
            | other -> failwith (other.ToString())
        loop (None, instance)

and internal fillList<'T> (reader : ODataReader, session : ISessionInternal) : IList<'T> = 
    let result = List<'T>()
    
    let rec loop (reader : ODataReader) = 
        if reader.Read() then 
            match reader.State with
            | ODataReaderState.EntryStart -> 
                createAndFillInstance<'T> (reader, session) |> result.Add
                loop (reader)
            | ODataReaderState.FeedEnd -> ()
            | _ -> loop (reader)
    loop (reader)
    let res = result :> IList<'T>
    res

let readResponse (response : ClientHttpResponseMessage, session : ISessionInternal, model : IEdmModel, ttype : Type) = 
    readeResponseMethodInfo.MakeGenericMethod([| ttype |]).Invoke(null, 
                                                                  [| response :> Object
                                                                     session :> Object
                                                                     model :> Object |])

// initial reading
let readResponseGeneric<'T> (response : ClientHttpResponseMessage, session : ISessionInternal, model : IEdmModel) = 
    use messageReader = new ODataMessageReader(response, new ODataMessageReaderSettings(), model)
    let payLoadKind = (messageReader.DetectPayloadKind() |> Seq.exactlyOne).PayloadKind
    
    let reader = 
        match (payLoadKind) with
        | ODataPayloadKind.Feed -> messageReader.CreateODataFeedReader()
        | ODataPayloadKind.Entry -> messageReader.CreateODataEntryReader()
        | _ -> failwith "Uknown payload kind"
    //
    fillList<'T> (reader, session)

let getComplexValue (metaData : MetaData) targetType value = 
    let properties = (metaData.GetSchemaElementForComplexType targetType).Properties()
    
    let oDataProps = 
        [ for prop in properties do
              let propName = prop.Name //prop.Name.Replace("Ex", "")
              let setter, x = (findSetter (targetType, propName)).Value
              let va, _, _ = getValueAndMemberName setter value
              yield ODataProperty(Name = prop.Name, Value = va) ]
    
    let typeName = MetaData.GetQualifiedName targetType
    ODataComplexValue(Properties = oDataProps, TypeName = typeName)

let postParameter<'T> (str, model, networkCredential, session : ISessionInternal, ``params`` : list<BodyParameter>) = 
    async { 
        let request : IODataRequestMessageAsync = upcast ClientHttpRequestMessage(Uri(str), networkCredential)
        request.SetHeader("session-guid", session.GuidKey)
        request.Method <- ODataConstants.MethodPost
        use messageWriter = new ODataMessageWriter(request, ODataMessageWriterSettings(), model)
        let paramWriter = messageWriter.CreateODataParameterWriter(null)
        paramWriter.WriteStart()
        //TODO: instead of this get types from functionimport so that we can handle null types properly
        for param in ``params`` do
            let writeParam writeF paramValue = 
                let paramType = paramValue.GetType()
                let kind = paramType |> session.SessionFactory.MetaData.GetTypeKind
                match kind with
                | EdmTypeKind.Complex -> 
                    let cx = getComplexValue session.SessionFactory.MetaData paramType paramValue
                    writeF (cx :> obj)
                | EdmTypeKind.Primitive -> writeF (paramValue)
                | other -> failwith "unsupported type %A" other
            match param.Value with
            | :? IEnumerable<obj> as col -> 
                let colWriter = paramWriter.CreateCollectionWriter(param.Name)
                let start = ODataCollectionStart()
                colWriter.WriteStart start
                for item in col do
                    writeParam colWriter.WriteItem item
                colWriter.WriteEnd()
            | single -> 
                let writeF = fun x -> paramWriter.WriteValue(param.Name, x)
                writeParam writeF param
        paramWriter.Flush()
        paramWriter.WriteEnd()
        let request = request :?> ClientHttpRequestMessage
        let! response = request.GetResponseAsync()
        return readResponseGeneric<'T> (response, session, model)
    }

// starting method for getting list
let internal getList<'T> (str, model, networkCredential, session : ISessionInternal) = 
    async { 
        let request = ClientHttpRequestMessage(Uri(str), networkCredential)
        (request :> IODataRequestMessageAsync).SetHeader("session-guid", session.GuidKey)
        let! response = request.GetResponseAsync()
        return readResponseGeneric<'T> (response, session, model)
    }

let internal create (str, model, networkCredential, session : ISessionInternal) = 
    { new IQuery with
          member this.ToListAsync<'T>() = getList<'T> (str, model, networkCredential, session) |> convertToTask
          
          //getList<'T> (str, model, networkCredential, session) |> convertToTask
          member this.UniqueResultAsync<'T>() = 
              let t = async { let! results = getList<'T> (str, model, networkCredential, session)
                              return results.SingleOrDefault() }
              t |> convertToTask<'T>
          
          member this.Query with get () = str
          member this.PostAsync(bodyParams) = postParameter<'T> (str, model, networkCredential, session, bodyParams |> Seq.toList) |> convertToTask }
